// ==UserScript==
// @name         笔趣阁优化
// @namespace    https://gitee.com/linhq1999/OhMyScript
// @version      5.3
// @description  专注阅读
// @author       LinHQ
// @match        http*://www.shuquge.com/*.html
// @exclude      http*://www.shuquge.com/*index.html
// @match        http*://www.sywx8.com/*.html
// @match        http*://www.biqugetv.com/*.html
// @match        http*://www.bqxs520.com/*.html
// @match        https://www.dshfood.net/*.html
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_xmlhttpRequest
// @require      https://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=953098
// @inject-into  auto
// @license MIT
// ==/UserScript==
'use strict';

// 网站设置
interface SiteConfig {
    // 匹配关键字
    desc: string;
    url: string;
    main: string;
    title: string;
    txt: string;
    filter: Array<string>;
    toc: string;
    tocJump: number;
    txtfilter: Array<string>;
    // 主要看书的详情页链接最后有没有斜杠
    nodash?: boolean;
    funcFilter?: () => void;
    /**
     * 传入 baseurl 即 site 的 url 方便进行 url 拼接
     */
    search?: (keywords: string, baseurl: string) => Promise<Link[]>;
}

// 总设置
interface HardCfg {
    sites: SiteConfig[];
    states: States;
    style: string;
}

// 链接项
interface Link {
    title: string;
    href: string;
}

// 持久化设置
interface States {
    fontSize: number;
    lineHeight: number;
    toc: boolean;
    flow: boolean;
}

/** 配置示例
* 建议在定制 search 函数时， rq 函数始终把参数写全
* "sites": [
*   {
*       "desc": "shuquge", 网站链接关键字
*       "url": "https://....", 网站首页链接
*       "main": "div.reader", 主要部分选择器
*       "title": ".reader h1", 标题选择器
*       "txt": "#content", 文字部分选择器
*       "toc": "dd a", 目录链接选择器
*       "tocJump": 12, 跳过前面多少章
*       "filter": ["div.header", "div.nav", "div.link"], 带有此选择器的元素将被删除
*       "txtfilter": ["shuqu"] 带有此关键字的行将被删除
*       "funcFilter"?: () => void, 自定义过滤器
*       "nodash"?: boolean, 判断是否应该在书籍详情页链接后加额外的斜杠
*       "search"?: (keywords: string, baseurl:string) => Promise<Link[]> 搜索行为
*   }
* ]
*/

(() => {
    // 缺省值，一般不用修改
    const lineHeight = 1.3;
    // const defaultFont = "楷体";
    const defaultFont = "Source Han Sans SC VF";
    let C: HardCfg = {
        "sites": [
            {
                "desc": "shuquge",
                "url": "https://www.shuquge.com/",
                "main": "div.reader",
                "title": ".reader h1",
                "txt": "#content",
                "toc": "dd a",
                "tocJump": 12,
                "filter": [
                    "div.header", "div.nav", "div.link", "img",
                    "#coupletleft", "#coupletright", "#HMRichBox"
                ],
                "txtfilter": ["shuqu"],
                "funcFilter": () => fd(document, "#content")?.previousSibling?.remove()
            },
            {
                "desc": "sywx",
                "url": "https://www.sywx8.com/",
                "main": "div#container",
                "title": "div>h1",
                "toc": "li a",
                "tocJump": 0,
                "txt": "div#BookText",
                "filter": ["div.top", ".link.xb", "#footer"],
                "txtfilter": ["最快更新", "松语", "本章完", "本章未完"],
                // javascript 不支持 gbk 的 uri 编码，所以无法实现
                // 但是用 gbk.js 就不一样了
                "search": async (keywords: string, baseurl: string) => {
                    let links: Link[] = [];
                    let doc = await rq({
                        "url": `https://www.sywx8.com/modules/article/search.php?searchkey=${$URL.encode(keywords)}`
                    }, 8000, "GBK");
                    for (let a of doc.querySelectorAll(".c_row .c_subject a")) {
                        // 这个网站比较特殊，链接默认是完整的
                        links.push({"title": `(sywx) ${a.textContent}`, "href": attr(a, "href")});
                    }
                    return links;
                }
            },
            {
                "desc": "bqxs",
                "url": "http://www.bqxs520.com/",
                "main": ".box_con",
                "title": "div.content_read h1",
                "toc": "#list dd a",
                "tocJump": 9,
                "txt": "#content",
                "filter": [".ywtop", ".header", ".nav", ".bottem1", ".lm", "#page_set", ".bookname~.box_con"],
                "txtfilter": ["请记住本书", "http"],
                "search": async (keywords: string, baseurl: string) => {
                    let links: Link[] = [];
                    let doc: Document = await rq({
                        "method": "POST",
                        "headers": {"Content-Type": "application/x-www-form-urlencoded"},
                        "url": encodeURI(`http://www.bqxs520.com/case.php?m=search`),
                        "data": `&key=${encodeURI(keywords)}`
                    }, 7000, "UTF-8");
                    for (let a of doc.querySelectorAll(".l .s2 a")) {
                        links.push({"title": `(bqxs) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href"))});
                    }
                    return links;
                }
            },
            {
                "desc": "biqugetv",
                "url": "https://www.biqugetv.com/",
                "main": ".box_con",
                "title": "div.content_read h1",
                "toc": "#list dd a",
                "tocJump": 0,
                "txt": "#content",
                "filter": [".ywtop", ".header", ".nav", ".bottem1", ".lm", "#page_set"],
                "txtfilter": [],
                "search": async (keywords: string, baseurl: string) => {
                    let links: Link[] = [];
                    let doc: Document = await rq({
                        "url": encodeURI(`https://www.biqugetv.com/search.php?keyword=${keywords}`)
                    }, 6000, "UTF-8");
                    for (let a of doc.querySelectorAll("h3 a")) {
                        links.push({"title": `(biqugetv) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href"))});
                    }
                    return links;
                }
            },
            {
                "desc": "dshfood",
                "url": "https://www.dshfood.net/",
                "main": ".box_con",
                "title": "div.content_read h1",
                "toc": "#list dd a",
                "tocJump": 9,
                "txt": "#content",
                "filter": [".ywtop", ".header", ".nav", ".bottem1", "#page_set", "#content>div"],
                "txtfilter": ["笔趣阁"],
                "nodash": true,
                "funcFilter": () => document.querySelectorAll("img")
                    .forEach(e => e.parentElement?.remove()),
                "search": async (keywords: string, baseurl: string) => {
                    let links: Link[] = [];
                    let doc: Document = await rq({
                        "method": "POST",
                        "headers": {
                            "Content-Type": "application/x-www-form-urlencoded",
                            "referer": "https://www.dshfood.net/so/"
                        },
                        "url": "https://www.dshfood.net/so/",
                        // 鉴于使用了 GBK 进行编码，不能再使用 URLSearchParams
                        "data": `?searchtype=articlename&searchkey=${$URL.encode(keywords)}&submit=`
                    }, 6000, "GBK");
                    for (let a of doc.querySelectorAll(".line a.blue")) {
                        links.push({"title": `(dshfood) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href"))});
                    }
                    return links;
                }
            }
        ],
        "states": {
            "fontSize": 16,
            "lineHeight": 16 * lineHeight,
            "toc": false,
            "flow": false
        },
        "style": `
            body {
                background-color: #EAEAEF !important;
            }

            .bqg.inject.win {
                width: 55vw !important;
                min-width: 600px;
                border: 2px double gray !important;
                border-radius: 8px;
            }

            .bqg.inject.txt {
                font-family: Calibri,'${defaultFont}',serif!important;
                background-color: #EAEAEF !important;
                padding: 0.5em 1em !important;
                margin: 0.5em auto !important;
                width: auto !important;
                white-space: pre-wrap;
            }

            .bqg.inject.title {
                color: black;
                background-color: #EAEAEF;
                font-family: Calibri,'${defaultFont}',serif!important;
                cursor: pointer !important;
            }

            .bqg.inject.title:hover {
                color: #0258d8 !important;
            }
            
            .hq.inject.toc {
                font-family: Calibri,sans-serif;
                width: 275px;
                position: fixed;
                top: 30px;
                left: 8px;
                /*目录默认是关闭的*/
                transform: translateX(-300px);
                opacity: 0;
                padding: 5px;
                display: flex;
                flex-flow: column;
                box-shadow: #7b7b7b 5px 4px 5px;
                transition-property: transform, box-shadow, opacity;
                transition-duration: .5s;
                transition-timing-function:cubic-bezier(0.35, 1.06, 0.83, 0.99);
                background: rgb(246 246 246 / 60%);
                backdrop-filter: blur(2px);
                border-radius: 8px;
            }

            .hq.inject ul {
                height: 280px;
                width: 100%;
                /*offsetTop 计算需要*/
                position:relative;
                overflow: auto;
            }

            .hq.inject ul li {
                cursor: pointer;
                margin: 2px;
                width: 95%;
                padding: 1px 4px;
                font-size: 12px;
                border-radius: 4px;
            }

            .hq.inject ul li:hover {
                background: #0258d8;
                color: #f6f6f6;
            }

            .hq.inject.toc>h3 {
                font-size: 1.1rem;
                font-weight: bold;
                border-radius: 2px;
                align-self: center;
                cursor: pointer;
                margin: 4px 0 8px 0;
            }

            .hq.inject.toc>h3:hover {
                color: #ffa631 !important;
            }

            .hq.inject.search {
                font-family: Calibri,sans-serif;
                width: 275px;
                position: fixed;
                top: 30px;
                padding: 5px;
                display: flex;
                flex-flow: column;
                transition: right 0.5s cubic-bezier(0.35, 1.06, 0.83, 0.99);
                background: rgb(246 246 246 / 60%);
                border-radius: 8px;
            }

            .hq.inject.search input {
                margin: 8px auto;
                width: 95%;
            }
            `
    };

    // 查询已经保存的字体信息
    let savedStates: string | null = localStorage.getItem("bqg_cfg");

    // 检查是否存在已有设置且和当前版本相符
    let _states: States;
    if (savedStates === null) {
        _states = C.states;
        console.warn("使用默认设置");
    } else {
        let cfg = JSON.parse(savedStates);
        let defaultStates = Object.keys(C.states);
        let cfg_ = Object.keys(cfg);
        let useSaved = true;
        // 检查键是否匹配
        if (defaultStates.length == cfg_.length) {
            for (let key of Object.keys(cfg)) {
                if (!defaultStates.includes(key)) {
                    useSaved = false;
                    break;
                }
            }
        } else {
            useSaved = false;
        }
        if (useSaved) {
            _states = cfg;
        } else {
            _states = C.states;
            console.warn("检测到版本变化，状态已重置");
        }
    }
    const states = new Proxy<States>(_states, {
        set(obj, prop, value, _recevier) {
            localStorage.setItem("bqg_cfg", JSON.stringify(obj))
            return Reflect.set(obj, prop, value)
        }
    })

    // 检测当前的网址，应用对应的设置
    let tmp: SiteConfig[] = C.sites.filter(site => document.URL.includes(site.desc));
    if (tmp.length == 0) {
        console.warn("没有匹配的设置，脚本已终止！")
        return;
    }
    let currentSite: SiteConfig = tmp[0];

    // 完成样式注入
    GM_addStyle(C.style);

    /**
     * 上一章，同时移除所有 flow 拼接结果
     */
    function prevChapter() {
        fd(document, "a", "上一")?.click();
    }

    /**
     * 下一章，同时移除所有 flow 拼接结果
     */
    function nextChapter() {
        fd(document, "a", "下一")?.click();
    }

    /**
     * 异步，向下拼页
     * 绑定到事件上时务必注意重复触发的情况
     */
    async function concatNextCh() {
        let next = fd(document, "a", "下一") as HTMLAnchorElement;
        let prev = fd(document, "a", "上一") as HTMLAnchorElement;
        let currentText = fd(document, currentSite.txt) as HTMLElement;
        try {
            let doc = await rq({url: next?.href});
            let text = fd(doc, currentSite.txt) as HTMLElement;
            // console.log(text.textContent)
            // 更好的性能
            currentText.insertAdjacentHTML("beforeend", "<br><hr style='border: unset;border-top: 1px solid gray; margin: ${states.lineHeight}px 0'>")
            currentText.insertAdjacentText("beforeend", txtFilter(text.innerText ?? "文本过滤错误", /(?![a-zA-Z0-9!.'"])\s+/))
            // /id/xxx_1.html -> /id/xxx_1
            let href = attr(next, "href").replace(/\.html$/, "");
            // 重新渲染目录，currentBookToc 不可能为 null
            renderTOC(JSON.parse(currentBookToc as string), ul, href);
            // 重设上一页和下一页按钮的链接
            prev.href = (fd(doc, "a", "上一") as HTMLAnchorElement).href;
            next.href = (fd(doc, "a", "下一") as HTMLAnchorElement).href;
        } catch (error) {
            currentText.innerText = currentText.innerText.concat("\n\n\t获取下一页错误，上下滚动以重新获取");
        }
    }

    // 目录切换
    function switchToc(open: boolean) {
        let toc = fd(document, ".hq.inject.toc") as HTMLElement;
        if (open) {
            toc.style.transform = "translateX(0)"
            toc.style.opacity = "1"
            toc.style.boxShadow = "box-shadow: #7b7b7b 5px 3px 4px 0px;"
            states.toc = true
        } else {
            toc.style.transform = "translateX(-300px)"
            toc.style.opacity = "0"
            toc.style.boxShadow = "box-shadow: #7b7b7b 5px 2px 0px 0px;"
            states.toc = false
        }
    }
    // 目录开关
    function toggleToc() {
        if (states.toc) {
            switchToc(false)
        } else {
            switchToc(true)
        }
    }

    /**
     * 根据 site 中的条件进行过滤，同时将缩进统一
     * 
     * @param itxt 需要过滤的，innerText 通用性最好
     * @param delim 默认的切分点，从网页解析得到的内容和 ajax 获取到的内容切分点不一致
     * @returns 过滤后字符串
     */
    function txtFilter(itxt: string, delim = /\n/g): string {
        // innerText 相对于 textContent 保留了视觉上的换行（块的换行）
        return itxt?.split(delim)
            ?.filter(line => {
                if (/^\s*$/.test(line)) return false
                // 去除白行和包含的关键字
                for (const keyword of currentSite.txtfilter) {
                    if (line.includes(keyword)) {
                        return false
                    }
                }
                return true;
            })
            // 全角空格对齐
            .map(line => `${"　".repeat(2)}${line.trim()}`)
            .join("\n\n")
    }

    if (states.flow) {
        // 变相 throttle 一下不然顶不住
        let loading = false;
        document.onscroll = async _ => {
            if (!loading && chkBoundry(true, window.innerHeight * 0.75)) {
                loading = true;
                // 意思是上一次拼页完过1.5秒才允许继续拼页，避免在加载下一页时反复调用拼页函数
                // 效果比固定延迟要稳定
                await concatNextCh();
                setTimeout(() => {loading = false;}, 1500);
            }
        }
    }

    // 对可变部分产生影响
    let doInject = function () {
        // 执行元素过滤
        currentSite.filter.forEach(filter => document.querySelectorAll(filter)?.forEach(ele => ele.remove()));
        // 执行自定义过滤
        if (currentSite.funcFilter) {
            currentSite.funcFilter();
        }

        // 应用已经保存的状态
        let textWin: HTMLElement = fd(document, currentSite.txt) as HTMLElement;
        textWin.setAttribute("style", `font-size:${states.fontSize}px;line-height:${states.lineHeight}px`);
        textWin.classList.add("bqg", "inject", "txt");

        // 执行文字过滤
        textWin.textContent = txtFilter(textWin.innerText ?? "文本过滤错误");

        let mainWin: HTMLElement = fd(document, currentSite.main) as HTMLElement;
        mainWin.classList.add("bqg", "inject", "win");

        let title: HTMLElement = fd(document, currentSite.title) as HTMLElement;
        title.title = "点击显示目录";
        title.classList.add("bqg", "inject", "title");
        title.onclick = (ev: MouseEvent) => {
            toggleToc();
            // 避免跳到上一章
            // 比下面的更为具体，所以有效。
            ev.stopPropagation();
        };

        // 阻止双击事件被捕获（双击会回到顶部）
        document.body.ondblclick = (ev: MouseEvent) => ev.stopImmediatePropagation();

        document.body.onclick = (ev: MouseEvent) => {
            let root = document.documentElement;
            let winHeight = window.innerHeight;
            // 下半屏单击下滚，反之上滚
            if (ev.clientY > root.clientHeight / 2) {
                if (chkBoundry() && !states.flow) nextChapter();
                window.scrollBy({top: (window.innerHeight - lineHeight) * 1})
            } else {
                if (chkBoundry(false)) {
                    prevChapter();
                }
                window.scrollBy({top: (window.innerHeight - lineHeight) * -1})
            }
        }

        document.body.onkeydown = (ev) => {
            switch (ev.key) {
                case "-":
                    states.fontSize -= 2;
                    textWin.style.fontSize = `${states.fontSize}px`;
                    states.lineHeight = states.fontSize * lineHeight;
                    textWin.style.lineHeight = `${states.lineHeight}px`;
                    break;
                case "=":
                    states.fontSize += 2;
                    textWin.style.fontSize = `${states.fontSize}px`;
                    states.lineHeight = states.fontSize * lineHeight;
                    textWin.style.lineHeight = `${states.lineHeight}px`;
                    break;
                case "j":
                    if (chkBoundry() && !states.flow) {
                        nextChapter();
                    } else {
                        window.scrollBy({top: window.innerHeight - states.lineHeight});
                    }
                    break;
                case "k":
                    // 考虑在 flow 模式下也允许上一章
                    if (chkBoundry(false) && !states.flow) {
                        prevChapter();
                    } else {
                        window.scrollBy({top: -1 * (window.innerHeight - states.lineHeight)});
                    }
                    break;
                case "h":
                    prevChapter();
                    break;
                case "l":
                    nextChapter();
                    break;
                case "t":
                    toggleToc();
                    break;
                case "s":
                    toggleSearch();
                    break;
                case "f":
                    states.flow = !states.flow;
                    break;
                default:
                    break;
            }
        }
    }
    // 先调用一次，后面是有变化时才会触发，避免有时无法起作用
    doInject();

    // 强力覆盖
    new MutationObserver((_, ob) => {
        doInject();
    }).observe(document.body, {childList: true});

    // 添加目录
    let toc = document.createElement("div");
    toc.className = "hq inject toc";
    toc.onclick = ev => ev.stopPropagation();
    // 已保存状态读取
    document.body.append(toc);
    if (states.toc) switchToc(true)
    // 目录状态指示灯
    let pointer = document.createElement("h3");
    // 当然也可以靠不同类名实现
    let pointerColors = {"loaded": "#afdd22", "loading": "#ffa631", "unload": "#ed5736"};
    pointer.title = "点击以重新加载目录";
    pointer.innerHTML = "目<span style='display: inline-block;width: 1em'></span>录";
    pointer.style.cursor = "pointer";
    pointer.style.color = pointerColors.unload;
    toc.append(pointer);
    // 目录列表
    let ul = document.createElement("ul");
    toc.append(ul);

    /**
     * 从源渲染目录到指定元素
     * 
     * @param toc 目录源
     * @param ul 容器
     * @param href 定位链接，格式 http://host/id/chp.html 中最短为 /id/chp 部分
     */
    function renderTOC(toc: Link[], ul: HTMLElement, href: string) {
        // 清空旧内容
        ul.innerHTML = "";
        let current = null;
        // 进度计数器
        let counter = 1;
        for (let lnk of toc) {
            let li: HTMLElement = document.createElement("li");
            li.textContent = lnk.title;
            // 根据传入的 href 是否包含目录中的链接来判定，因为有的网站包含子页面 XXXX_1.html 形式
            // 比对时标准目录链接 lnk: /id/chp.html 之中，仅取用 chp
            let last = lnk.href.replace(".html", "") ?? "";
            if (current == null && href.includes(last)) {
                li.innerHTML = `${lnk.title}<span style="flex: 1;"></span>${(counter / toc.length * 100).toFixed(1)}%`
                current = li;
            }
            li.onclick = (ev) => {
                document.location.href = lnk.href;
                ev.stopPropagation();
            };
            ul.append(li);
            counter++;
        }

        // 渲染完修改指示灯状态
        pointer.style.color = pointerColors.loaded;
        // 滚动到当前位置，并高亮
        if (current !== null) {
            current.setAttribute("style", "display:flex;font-weight:bold;background: #0258d8;color: #f6f6f6;");
            ul.scrollTo({top: current.offsetTop - 130});
        }
    }

    /**
     * 获取目录信息
     * 
     * @param currentBookLink 当前书的链接，用作存储的键
     * @param pointer 指示灯，在需要的时候修改状态
     */
    async function fetchTOC(currentBookLink: string, pointer: HTMLElement) {
        // 修改指示灯状态
        pointer.style.color = pointerColors.loading;
        try {
            let doc: Document = await rq({url: currentBookLink})
            let tocs = doc.querySelectorAll(currentSite.toc);
            let data: Link[] = [];
            // 序列化存储准备
            for (let link of tocs) {
                // 使用字面意义上的链接 /chapter.html 而不是 http://**/id/chapter.html 以减小存储量
                data.push({"title": link.textContent ?? "", "href": attr(link, "href")});
            }
            if (currentSite.tocJump) data = data.slice(currentSite.tocJump);
            // 缓存目录信息
            let stdata = JSON.stringify(data);
            sessionStorage.setItem(currentBookLink, stdata);
            // 更新变量，避免章节拼接时以为找不到
            currentBookToc = stdata;
            renderTOC(data, ul, href);
        } catch (_) {
            pointer.style.color = pointerColors.unload;
        }
    }

    let source = document.URL.split("/");
    source.pop()
    // 用来定位的 url
    let href = document.URL.replace(/\.html$/, "");
    // 最后加斜杠保险
    let currentBook = source.join("/");
    if (!currentSite.nodash) {
        currentBook += "/";
    }
    let currentBookToc: string | null = sessionStorage.getItem(currentBook);
    if (currentBookToc === null) {
        fetchTOC(currentBook, pointer)
    } else {
        renderTOC(JSON.parse(currentBookToc), ul, href);
    }
    // 单击指示灯刷新目录缓存
    pointer.onclick = _ => fetchTOC(currentBook, pointer);

    // 添加聚合搜索
    let searchBox = document.createElement("div");
    searchBox.onclick = ev => ev.stopPropagation();
    searchBox.onkeydown = ev => ev.stopPropagation();
    searchBox.className = "hq inject search";
    searchBox.style.right = "-300px";
    searchBox.innerHTML = `
    <input id="insearch" type="search" placeholder="至少输入两个字"/>
    <span style="align-self:center;margin-bottem: 4px;color: ${pointerColors.loaded}">已就绪</span>
    `;
    document.body.append(searchBox);
    let inputBox = fd(searchBox, "#insearch") as HTMLFormElement;
    let search_ul = document.createElement("ul");
    searchBox.append(search_ul);
    let search_pointer = fd(searchBox, "#insearch~span") as HTMLElement;

    // debounce 一下不然顶不住
    let timer: number | null = null;
    inputBox.oninput = _ => {
        if (timer !== null) clearTimeout(timer);
        timer = setTimeout(async () => {
            // 放外面也可
            if ((inputBox as HTMLFormElement)?.value.length < 2) return;

            // 更新指示灯
            search_pointer.textContent = `正在搜索：${inputBox.value}`;
            search_pointer.style.color = pointerColors.loading;

            let requests: Promise<Link[]>[] = [];
            let others: Link[] = [{"title": "没有搜索结果，也可以看看:", "href": "#"}];
            for (let s of C.sites) {
                if (s.search !== undefined) {
                    // 搜索开始
                    requests.push(s.search((inputBox as HTMLFormElement)?.value, s.url));
                } else {
                    others.push({"title": s.desc, "href": s.url})
                }
            }
            let result_count = 0, failed = 0;
            let list = await Promise.allSettled(requests);
            // 获取结果后清空旧内容
            search_ul.innerHTML = "";
            for (let site of list) {
                if (site.status === "fulfilled") {
                    for (let lnk of site.value) {
                        let li = document.createElement("li");
                        li.textContent = lnk.title.trim();
                        li.onclick = ev => GM_openInTab(lnk.href, {active: true})
                        search_ul.append(li);
                        result_count++;
                    }
                } else {
                    failed++;
                }
            }
            // 处理一下没有结果的情况，把没有实现 search 的网站摆上去
            if (result_count === 0) {
                for (let o of others) {
                    let li = document.createElement("li");
                    li.textContent = o.title;
                    li.onclick = ev => GM_openInTab(o.href, {active: true});
                    search_ul.append(li);
                }
            }

            // 更新指示
            search_pointer.textContent = `搜索完成：${result_count} 条结果 [${failed} 错误]`;
            search_pointer.style.color = pointerColors.loaded;
        }, 1000);
    }

    // 搜索框开关
    function toggleSearch() {
        if (parseInt(searchBox.style.right) < 0) {
            searchBox.style.right = "8px";
        } else {
            searchBox.style.right = "-300px";
        }
    }

    /*
      以下是工具函数
     */

    /**
     * 发起请求
     * 
     * @param details 油猴标准请求格式，onload,onerror,responseType 会被忽略
     * @param timeout 超时时间 默认：5000
     * @param encoding 请求数据的编码 默认：当前所在页面的编码
     * @returns Promise<Document>
     */
    function rq(details: Tampermonkey.Request, timeout = 5000, encoding?: string): Promise<Document> {
        // 自动探测一手
        if (!encoding) encoding = document.characterSet;
        return new Promise((res, rej) => {
            details.onerror = rej;
            details.ontimeout = rej;
            details.timeout = timeout;

            details.responseType = "arraybuffer";
            details.onload = resp => {
                if (resp.status != 200) rej();
                let decoder = new TextDecoder(encoding);
                res(new DOMParser()
                    .parseFromString(decoder.decode(resp.response), "text/html"));
            };
            GM_xmlhttpRequest(details);
        });
    }

    /**
     * 返回符合条件的第一个元素
     * 
     * @param doc 被查找的文档
     * @param selector 选择器
     * @param text 可选 元素的文本（子字符串）
     * @returns 符合条件的元素
     */
    function fd(doc: Document | HTMLElement, selector: string, text?: string | undefined): HTMLElement | null {
        if (text) {
            for (let e of doc.querySelectorAll(selector)) {
                if (e.textContent?.includes(text)) {
                    return e as HTMLElement;
                }
            }
        } else {
            return doc.querySelector(selector);
        }
        return null;
    }

    /**
     * 拼接 URL
     * 
     * @param host 网站域名
     * @param path 一般是链接之中的相对路径
     * @returns 完整的 URL
     */
    function concatURL(host: string, path: string): string {
        let url = new URL(host);
        url.pathname = path;
        return url.toString();
    }

    /**
     * 如果是 a 标签，且想要获取字面上的 href，必须使用此方法，不可以用 a.href
     * 
     * @param ele 标签名
     * @param attr 属性名
     * @returns 属性值
     */
    function attr(ele: Element | HTMLElement, attr: string): string {
        return ele.getAttribute(attr) ?? "";
    }

    /**
     * 检查当前位置是否处于边界
     * 
     * @param bottom 是否检查到达底部，否则检查是否处于顶部
     * @param range 距离底部多少，默认是 0（最底部）
     * @returns boolean
     */
    function chkBoundry(bottom = true, range = 0): boolean {
        let root = document.documentElement;
        let winHeight = window.innerHeight;
        if (bottom) {
            return (root.scrollTop + winHeight + range >= root.scrollHeight)
        } else {
            return (root.scrollTop == 0);
        }
    }
})()
